/*
This software is released under the New BSD License as listed below. 
For more information, visit: http://www.opensource.org/licenses/bsd-license.php

Copyright (c) 2010, Evan Bowling
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
    * Redistributions of source code must retain the above copyright
      notice, this list of conditions and the following disclaimer.
    * Redistributions in binary form must reproduce the above copyright
      notice, this list of conditions and the following disclaimer in the
      documentation and/or other materials provided with the distribution.
    * Neither the name of Evan Bowling nor the
      names of its contributors may be used to endorse or promote products
      derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL Evan Bowling BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/



/*
 * Purpose: self-executing function for initializing tasks, and avoiding variable overlap 
 **/
(function() {
	// check to see if the same variable name has already been used
	if(typeof OpenError != "undefined")
	{
		throw new Error("global variable OpenError previously defined.");
	}
	
	/*
	 * Function: OpenError
	 * Purpose: used to define global constructor for openerror object
	 **/
	OpenError = function()
	{
		
         if (! (this instanceof arguments.callee) )
         {  
         	// checks to ensure a new operator was used
            return new OpenError(arguments);  
         }
		 

/*
		var assert = function() { 
			if( arguments.length < 2)
			{
				console.info('Warning: assert function used improperly');
			}
			else
			{
			}
			return true;
		}*/
	

		// ----------------------------
		// Private Variables
		// ----------------------------
		 
		// {Integer} - power to be used in Inverse Distance Weighting (IDW) method
		var k = 2;
		
		// {String} - character representing the units of the standard deviation
		var units = 'm'; // meters is the only current option
		
		// {Integer} - distance to be used to normalize the 
		var standardDeviation = 90;
		
		// {Boolean} - value used to determine whether or not the errors generated for each data point should be spatially 
		//             correlated or not
		var correlated = true;
		
		// {JSON Object} - GeoJSON MultiPoint object
		var dataSet = {};
		
		// {Array} - 2D array of distances between each point and all the others. This is needed to calculate correlated errors.
		// NOTE - this is a design decision not to calculate distances on-the-fly, which can be quite expensive. 
		var distanceDataSet = [];
		
		// {Boolean} - flag used to determine whether statistics should be gathered and displayed using HTML
		var debug = true;
		
		// {String} - HTML DOM id to use for outputting a table of debugging information
		var debugSectionId = 'debug';
		
		
		 
		// ----------------------------
		// Public Accessor Functions
		// ----------------------------
		
		// GETTERS ----------------------
		
		this.getK = function() { return k; };
		this.getUnits = function() { return units; };
		this.getStandardDeviation = function() { return standardDeviation; };	
		this.getCorrelated = function() { return correlated; };
		this.getDataSet = function() { return dataSet; };
		this.getDistanceDataSet = function() { return distanceDataSet; };
		this.getDebug = function() { return debug; };
		this.getDebugSectionId = function() { return debugSectionId; };

		
		// SETTERS ----------------------
		
		/*
		 * Method: setK
		 *   Purpose:  validate the input and assign to the private variable k
		 *
		 * Inputs:
		 * {Integer} - power to be used in the IDW method
		 **/
		this.setK = function()
		{
			if(arguments[0] && isFinite(arguments[0])) // check to ensure the first argument exists and is a valid number
			{
				// TODO: ensure it is not a float value
				k = arguments[0];
			}
			else
			{
				console.info('Warning: invalid argument for setK function call');
			}
		};
		
		/*
		 * Method: setDebug
		 *   Purpose:  validate the input and assign to the private variable debug
		 *
		 * Inputs:
		 * {Boolean} - var to decide whether or not to post debug info to the screen
		 **/
		this.setDebug = function()
		{
				// isFinite evaluates to true with both boolean values
			if(arguments[0]) // check to ensure the first argument exists and is a valid number
			{
				// TODO: ensure it is a boolean var
				debug = arguments[0];
			}
		};
		
		/*
		 * Method: setStandardDeviation
		 *   Purpose:  validate the input and assign to the private variable standardDeviation
		 *
		 * Inputs:
		 * {Float} - the number of units to alter the normally distrubted random errors
		 **/	 
		this.setStandardDeviation = function()
		{
			if(arguments[0] && isFinite(arguments[0]))
			{
				standardDeviation = arguments[0];
			}
		};
		
		/*
		 * Method: setCorrelated
		 *   Purpose:  validate the input and assign to the private variable correlated
		 *
		 * Inputs:
		 * {Boolean} - power to be used in the IDW method
		 **/
		this.setCorrelated = function()
		{
			if(arguments[0] === true || arguments[0] === false ) // check to ensure the first argument exists and is a boolean
			{
				correlated = arguments[0];
			}
			else
			{
				console.info('Warning: invalid argument for setCorrelated function call');
			}
		};
		
		/*
		 * Method: setDataSet
		 *   Purpose:  validate the input and assign to the private variable dataSet
		 *
		 * Inputs:
		 * {JSON Object} - GEOJSON MultiPoint object containing the data set to be altered
		 **/
		this.setDataSet = function()
		{
			var d = arguments[0];
			
			if(arguments[0])
			{
				d = arguments[0];
			}
			
			if(d.type && d.type == "MultiPoint" && d.coordinates)
			{
				// TODO: ensure that d.coordinates is an array containing floats
				dataSet = d;
			}
		};
		
		/*
		 * Method: setDistanceDataSet
		 *   Purpose: validate inputs and assign to the private variable distanceDataSet
		 *
		 * Input:
		 * {Array} - 2D array where each inner array represents the set of distances from one point to all the rest
		 **/
		this.setDistanceDataSet = function()
		{
			//TODO: consider strict and non-strict forms of validating
			if(arguments[0] && (typeof arguments[0] == "object") && arguments[0].length > 0)
			{
				var errorFree = true;
				var innerArrayLength = arguments[0][0].length;
				for(var i=0;i<arguments[0].length;i++)
				{
					if(arguments[0][i].length != innerArrayLength) // the inner set of arrays must all have the same length
					{
						errorFree = false;
					}
				}
				
				if( errorFree)
				{
					distanceDataSet = arguments[0];
				}
				else
				{
					throw new Error('Warning: invalid argument for setDistanceDataSet function call');
				}
			}
			else
			{
				throw new Error('Warning: invalid argument for setDistanceDataSet function call');
			}
		};
 



     };
     
     /*
      * Namespace: prototype
      * Purpose: prevents users from deleting member variables in instances
      **/
     OpenError.prototype =
	 {
	
		/*
		 * Namespace: constants
		 * Purpose: container for library specific constants and functions        
		 **/
     	Constants :
     	{
			// TODO: research whether or not a web service exists to find these values
		
			/*
			 * Namespace: Sphere
			 *   Purpose: A simple model of the Earth - this assumes that a degree of 
			 *            latitude spans the same distance at any point on Earth. In 
			 *            reality, it can differ by up to 1000 meters.
			 **/
			Sphere : {
				/**
				 * Property: METERS_IN_LAT
				 * {Integer} an approximation of the amount of meters in a degree of latitude.
				 */
				METERS_IN_LAT:111132,
				
				
				/**
				 * Method: metersInLon
				 *   Purpose: Get the length of a degree of longitude in meters.
				 *
				 * Inputs:
				 * {Float} - the degree of latitude to calculate the 
				 * Returns:
				 * {Float} result - the number of meters in a degree of longitude at a given 
				 */
				metersInLon:function()
				{
					var lat = arguments[0];
					if( !isFinite(arguments[0]) )
					{
						throw new Error("Latitude not specified correctly or is not present");
					}
					else if( (lat < -89.5) || (lat > 89.5))
					{
						throw new Error("Latitude must be between -90 and 90");
					}
					var result;
					try
					{
						var rad = lat * 180 / Math.PI;
						result = Math.cos( rad ) * OpenError.prototype.Constants.Sphere.METERS_IN_LAT;
					}
					catch(e)
					{
						throw e;
					}
					//assumes the lat value is in decimal degrees
					return result;
				}
			}
			

			
			/**
			 * Namespace: Spheroid
			 * A more accurate representation of the Earth
			 */
			//Spheroid :
			//{
				//todo: complete implementation
			//}
     	},

		/*
		 * Namespace: random
		 *   Purpose: contain functions for special randomization cases
		 **/
		Random :
		{
			/*
			 * Method: boxMuller
			 *   Purpose: generate two independent normally distributed values with a standard deviation of 1
			 *            makes use of the box muller algorithm
			 *          
			 * Returns:
			 *   {GEOJSON Point Object} - a coordinate as specified by the OGC-compliant GEOJSON 1.0 format
			 **/
			boxMuller : function()
			{
				// TODO: implement precision control
				var x = Math.random();
				var y = Math.random();
				var r = Math.sqrt( -2 * Math.log(x) );
				var theta = 2 * Math.PI * y;
					
				var point1 = r * Math.cos( theta ); 
				var point2 = r * Math.sin( theta );
				
				var rotation = Math.random()*2*Math.PI;
				var new_x = -point1*Math.sin(rotation);
				var new_y = point1 *Math.cos(rotation);
					
				return { "type": "Point", "coordinates": [new_x, new_y] };
			}
     	},
 		
		/*
		 * Function: generateDataset
		 *   Purpose:  create an array of the dataset altered by random values using the specified standard deviation 
		 *             on a normal distribution
		 *
		 * Returns:
		 *  { GEOJSON MultiPoint Object} - the altered dataset to be used in other mapping applications
		 **/
     	generateDataset : function()
     	{
			// ensure that the dataset is set before running any calculations
			if( !this.getDataSet().coordinates || this.getDataSet().coordinates.length === 0)
			{
				throw new Error('Warning: DataSet is empty.');
			}
			
				
			// TODO: implement randomizing the order in which points are visited
			var visited_points = Array(); // array of visited index values (e.g. visited_points[x] = 2 )
			
			var sigma_points = Array(); // (e.g. sigma_points[x] = {'lat':2, 'lon':1} )
			var final_points = Array();
			
			var debugResult = "";
			
			if( this.getCorrelated() )
			{
	
				if(this.getDebug())
				{
		
					debugResult += "<table style='border-style:inset;border-color:gray;'><tbody><tr><th id=\"index\">Point Index</th><th id=\"lat\">Lat</th><th id=\"lon\">Lon</th><th id=\"total-weights\">Total IDW Weights</th><th id=\"sigma_lat\">Unaltered Sigma Lat</th><th id=\"sigma_lon\">Unaltered Sigma Lon</th><th id=\"idw-lat\">IDW.Lat</th><th id=\"idw-lon\">IDW.Lon</th><th id=\"new_lon\">Lat'</th><th id=\"new_lon\">Lon'</th></tr>";
				}
				
 				// Generate a Random Index
 				/*
				var i = 0;
				var new_index = new Array();
				
				
				for(i=0;i<this.getDataSet().coordinates.length;i++)
				{
					var j = 0;
					var new_index_found = false;
					
					// generate random integers until an unused value is created
					while( !new_index_found)
					{	
						var same_index_found = false;
						
						// generate a random number from 0 to the size of the dataset
						var r = Math.floor( Math.random() * this.getDataSet().coordinates.length)
						for(j=0;j<new_index.length;j++)
						{
							if(new_index[j] == r)
							{
								same_index_found = true;
							}
						}
						
						// check if the random number generated was already added to the index array
						if(!same_index_found)
						{
							new_index[ new_index.length] = r;
							new_index_found = true;
							
						}
					}
				}*/
				
				
				for(var i =0;i<this.getDataSet().coordinates.length; i++)
				{
					
					// -generate uninfluenced random sigma
					var new_sigma = OpenError.prototype.Random.boxMuller();

					if(visited_points.length === 0)
					{
	
		
						// alter random set by standard deviation
						// need to make this flexible enough to account for other models of Earth
						//if(this.getUnits() == 'm')
						//{
						//		new_sigma.coordinates[0] = new_sigma.coordinates[0] * (this.getStandardDeviation() / OpenError.prototype.Constants.Sphere.METERS_IN_LAT);
						//		new_sigma.coordinates[1] = new_sigma.coordinates[1] * (this.getStandardDeviation() / OpenError.prototype.Constants.Sphere.metersInLon( new_sigma.coordinates[0]));
						//}
						
						var sd = {}; // new standard deviation object to account for 
						if(this.getUnits() == 'm')
						{
							sd.lat = this.getStandardDeviation() / OpenError.prototype.Constants.Sphere.METERS_IN_LAT;
							sd.lon = this.getStandardDeviation() / OpenError.prototype.Constants.Sphere.metersInLon( new_sigma.coordinates[0]);
						}
						
						new_sigma.coordinates[0] = new_sigma.coordinates[0] * sd.lat;
						new_sigma.coordinates[1] = new_sigma.coordinates[1] * sd.lon;
					
						// -add point to list of sigmas
						sigma_points.push( new_sigma );
						
						if(this.getDebug())
						{
							debugResult += "<tr><td headers=\"index\" >"+(i+1)+"</td><td headers=\"lat\" >"+this.getDataSet().coordinates[i][0]+"</td><td headers=\"lon\">"+this.getDataSet().coordinates[i][1]+"</td>";
							debugResult += "<td headers=\"total-weights\">"+0+"</td>";
							debugResult += "<td headers=\"sigma_lat\">"+new_sigma.coordinates[0]+ "</td><td headers=\"sigma_lon\">"+new_sigma.coordinates[1];
							debugResult += "<td headers=\"idw-lat\">"+0+"</td><td headers=\"idw-lon\">"+0+"</td>";
							debugResult += "<td headers=\"new_lat\">"+(this.getDataSet().coordinates[i][0]+new_sigma.coordinates[0])+ "</td><td headers=\"new_lon\">"+(this.getDataSet().coordinates[i][1]+new_sigma.coordinates[1]);
							debugResult += "</td></tr>";
						}
						
						final_points.push( {'lat':(this.getDataSet().coordinates[i][0]+new_sigma.coordinates[0]), 'lon':(this.getDataSet().coordinates[i][1]+new_sigma.coordinates[1])});
						
					}
					else
					{
					
						// ---------------------------
						// Caclulate the total weights
						// ---------------------------
						var total_weights = 0;
						var j =0;
						var index=0;
						var distanceSet = this.getDistanceDataSet();
						for(j=0;j<visited_points.length;j++)
						{
							index = visited_points[j];
							total_weights += (1 / Math.pow( distanceSet[i][index], this.getK()) );
						}
						
						// ---------------------------
						// Calculate the idw for both lat and lon
						// ---------------------------
						var z_lat = 0;
						var z_lon = 0;
						for(j=0;j<visited_points.length;j++)
						{
							index = visited_points[j];
							
							var weight = ((1 / Math.pow(distanceSet[i][index],this.getK()) ) / total_weights);
							
							z_lat += (sigma_points[index].coordinates[0] * weight);
							z_lon += (sigma_points[index].coordinates[1] * weight);
						}
						
						// ----------------------------------------
						// Use idw to offset the zero value in the 
						// ----------------------------------------
	
						
						// alter random set by standard deviation
						// need to make this flexible enough to account for other models of Earth
						//if(this.getUnits() == 'm')
						//{
						//		new_sigma.coordinates[0] = new_sigma.coordinates[0] * (this.getStandardDeviation() / OpenError.prototype.Constants.Sphere.METERS_IN_LAT);
						//		new_sigma.coordinates[1] = new_sigma.coordinates[1] * (this.getStandardDeviation() / OpenError.prototype.Constants.Sphere.metersInLon( new_sigma.coordinates[0]));
						//}
						
						var sd = {}; // new standard deviation object to account for 
						if(this.getUnits() == 'm')
						{
							sd.lat = this.getStandardDeviation() / OpenError.prototype.Constants.Sphere.METERS_IN_LAT;
							sd.lon = this.getStandardDeviation() / OpenError.prototype.Constants.Sphere.metersInLon( new_sigma.coordinates[0]);
						}
						
						new_sigma.coordinates[0] = new_sigma.coordinates[0] * sd.lat;
						new_sigma.coordinates[1] = new_sigma.coordinates[1] * sd.lon;
		
						if(this.getDebug())
						{
							//todo: incorporate this debug section into the one below
							debugResult += "<tr><td headers=\"index\" >"+(i+1)+"</td><td headers=\"lat\" >"+this.getDataSet().coordinates[i][0]+"</td><td headers=\"lon\">"+this.getDataSet().coordinates[i][1]+"</td>";
							
							debugResult += "<td headers=\"total-weights\">"+total_weights+"</td>";
							
							debugResult += "<td headers=\"sigma_lat\">"+new_sigma.coordinates[0]+ "</td><td headers=\"sigma_lon\">"+new_sigma.coordinates[1];
							
							debugResult += "<td headers=\"idw-lat\">"+z_lat+"</td><td headers=\"idw-lon\">"+z_lon+"</td>";
						}
						
						new_sigma.coordinates[0] += z_lat;
						new_sigma.coordinates[1] += z_lon;
						
						if(this.getDebug()) 
						{
							debugResult += "<td headers=\"new_lat\">"+(this.getDataSet().coordinates[i][0]+new_sigma.coordinates[0])+ "</td><td headers=\"new_lon\">"+(this.getDataSet().coordinates[i][1]+new_sigma.coordinates[1]);
							debugResult += "</td></tr>";
						}
						
						sigma_points.push( new_sigma );
						final_points.push( {'lat':(this.getDataSet().coordinates[i][0]+new_sigma.coordinates[0]), 'lon':(this.getDataSet().coordinates[i][1]+new_sigma.coordinates[1])});
						
						
					}
					visited_points.push(i);
				}
				
				
				// assign the debug HTML table string to the content of an HTML element on the page
				if(this.getDebug())
				{
					debugResult += "</tbody></table>";
					var div = document.getElementById( this.getDebugSectionId() );
					if(div !== null)
					{
						div.innerHTML = debugResult;
					}
				}
			}
			else
			{
				// generate uncorrelated errors for the data set
	
	
				if(this.getDebug())
				{
					debugResult += "<table style='border-style:inset;border-collapse:collapse;border-style:outset;border-color:gray;'><tbody><tr><th id=\"index\">Point Index</th><th id=\"lat\">Lat</th><th id=\"lon\">Lon</th><th id=\"sigma_lat\">Unaltered Sigma Lat</th><th id=\"sigma_lon\">Unaltered Sigma Lon</th><th id=\"sd_lat\">SD Lat</th><th id=\"rad_lat\">Lat(rad)</th><th id=\"sd_lon\">SD Lon</th><th id=\"new_lat\">Lat'</th><th id=\"new_lon\">Lon'</th></tr>";
				}
				
				
				for(var i =0;i<this.getDataSet().coordinates.length; i++)
				{
					
					// -generate uninfluenced random sigma
					var new_sigma = OpenError.prototype.Random.boxMuller();
					
					if(this.getDebug())
					{
						//todo: incorporate this debug section into the one below
						debugResult += "<tr><td headers=\"index\" >"+(i+1)+"</td><td headers=\"lat\" >"+this.getDataSet().coordinates[i][0]+"</td><td headers=\"lon\">"+this.getDataSet().coordinates[i][1]+"</td>";
						
						
						debugResult += "<td headers=\"sigma_lat\">"+new_sigma.coordinates[0]+ "</td><td headers=\"sigma_lon\">"+new_sigma.coordinates[1];
						
					}
					
					// alter random set by standard deviation
					// need to make this flexible enough to account for other models of Earth
					//if(this.getUnits() == 'm')
					//{
					//		new_sigma.coordinates[0] = new_sigma.coordinates[0] * (this.getStandardDeviation() / OpenError.prototype.Constants.Sphere.METERS_IN_LAT);
					//		new_sigma.coordinates[1] = new_sigma.coordinates[1] * (this.getStandardDeviation() / OpenError.prototype.Constants.Sphere.metersInLon( new_sigma.coordinates[0]));
					//}
					
					var sd = {}; // new standard deviation object to account for 
					if(this.getUnits() == 'm')
					{
						sd.lat = this.getStandardDeviation() / OpenError.prototype.Constants.Sphere.METERS_IN_LAT;
						var r = this.getDataSet().coordinates[i][0] * (Math.PI / 180);
						sd.lon = this.getStandardDeviation() / ( Math.cos(r) * OpenError.prototype.Constants.Sphere.METERS_IN_LAT);
						if(this.getDebug())
						{
							
							debugResult += "<td headers=\"sd_lat\">"+sd.lat+ "</td><td headers=\"rad_lat\">"+r+"</td><td headers=\"sd_lon\">"+sd.lon;
							
						}
					}
					
					new_sigma.coordinates[0] = new_sigma.coordinates[0] * sd.lat;
					new_sigma.coordinates[1] = new_sigma.coordinates[1] * sd.lon;
					
					if(this.getDebug())
					{
						
						debugResult += "<td headers=\"new_lat\">"+new_sigma.coordinates[0]+"</td><td headers=\"new_lon\">"+new_sigma.coordinates[1]+"</td></tr>";
						
					}

					
					final_points.push( {'lat':(this.getDataSet().coordinates[i][0]+new_sigma.coordinates[0]), 'lon':(this.getDataSet().coordinates[i][1]+new_sigma.coordinates[1])});
					
				}
				
				// assign the debug HTML table string to the content of an HTML element on the page
				if(this.getDebug())
				{
					debugResult += "</tbody></table>";
					var div = document.getElementById( this.getDebugSectionId() );
					if(div !== null)
					{
						div.innerHTML = debugResult;
					}
				}
			}


			return final_points;
     	}
     	
     };

})();